Skip to content

track_tasks: a refused batch save leaves the board mutated, so a ghost card survives the error - #2763

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-track-tasks-rollback
Nov 10, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-track-tasks-rollback

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2761

Owner: Dev-1
Written-by: Dev-1
Bot: Dev-1
Claim-Id: 0cbab9084d364946a62fcc0a24fb58d3
Base: f2ef4df
Risk tier: T2

What changed

track_tasks reached state.tasks before it saved: the main batch committed its staged copy into the board at the end of the loop, and the four early paths (claim: true, the handoff pool, rework, release) mutated the live card in place; the single save() came after. A store write that refuses — the temp name is not a plain file of its own, the state lock was lost, the disk is full — therefore left cards on the board the file never took, and the next unrelated save wrote them out. The probe in the issue reproduces it with a soft-linked temp name: track_tasks answers Not saved: …, state.tasks already shows Ghost card, and one later track_tasks puts both cards in state.json.

After: each of the five save points snapshots the board first and puts it back when the store refuses, then rethrows the store's own error. The restore deletes a card the batch created, puts back a card the batch removed, and rewrites every key of a card the batch changed in the snapshot's order, so JSON.stringify(rt.state.tasks) is byte-identical to the call's start. state.revision is not restored: the store increments it before the write, and a refused write does not rewind it. The room claim line (announceClaim → postClaimLine) moved after a successful save, so a refused claim posts nothing.

The snapshot is deep, not the shallow { ...state.tasks } the issue sketches. A shallow copy is enough for the main path alone, because that path mutates a structuredClone staging copy (#2597); it is not enough for the four early paths, which mutate the live card (live.claimedBy = botId, openRework(task, …), requeue(task, …)), so a shallow snapshot holds the very objects the rollback has to undo. The test matrix below fails 4 of 7 cases if the snapshot is shallow (claim, release, rework, and the claim-line half), which is why this PR deviates from that note. Restoring writes the old references back into the same keys, so a caller already holding a card keeps seeing that object.

revision monotonicity and saves.length === 1 are asserted, and the refusal is a real store refusal (tempWritable), not a stubbed save: the temp name is linked to a file outside the home, whose bytes are read back to prove nothing was written through it.

Behaviour change: claimCue and the claim's own save

postClaimLine records claimCue on the card so an identical line is not posted twice (#1253). Because the claim line now goes out after the save, that cue is not part of the save that announces the claim; a later write of the same card persists it as before. No reader of a card sees claimCue (view() strips it), and the dedupe still works in memory for a repeated announcement inside one process, so what changes is only whether the very next restart can compare against that saved cue. Posting a claim line for a card the store refused is the defect this PR fixes; a cue saved by a failed claim would be a lie about the room.

Verification at the tip

Verification commit (same tree as head): 798117eeda19314f3b52c29cab8720e673b65a10
Head: 5699bdb — tree 24dccee960ab17fcf95cbdff838fe02abd76942b, the tree every run below was taken on; .flybot_push.py re-committed the verified tree on the live base.

Ran:

  • red first on a clean f2ef4dfaccedd40315bd585474d0902118dc0ba8 worktree carrying only core/test/track-tasks-batch-refusal.test.js from this branch (core/src/host/tasks.js at the base): # tests 10 / # pass 3 / # fail 7 — the three track_tasks creates the first card before refusing the second item of the batch #2597 cases pass, the seven track_tasks commits the batch before save(): a refused store write leaves ghost cards in memory #2761 cases fail:
    • a refused save leaves a new batch out of state.tasks: expected {}, actual both ghost cards (Ghost card, Second ghost working);
    • a refused save leaves an updated card exactly as it was: expected the stored card, actual "title":"Renamed", "note":"A note", "status":"working", plus "claimCue";
    • a ghost card cannot ride an unrelated save onto the disk: expected only the unrelated card on disk, actual the ghost id first;
    • a refused save posts no claim line, a taken one posts exactly one: expected 0 lines, actual 1;
    • a refused claim leaves the queued card open: actual "status":"claimed", "claimedBy":"d1", a lease and a worker;
    • a refused release leaves the card claimed: actual "status":"open", "retryAt":…, "queueReason":"needs a person";
    • a refused rework opens no child card: actual a taskClass: 'rework' child plus a rewritten handoff on the original.
  • after the fix, the same file on this tip: # tests 10 / # pass 10 / # fail 0
  • TZ=UTC npm run verify with ARCH_DOCS_BASE=f2ef4dfaccedd40315bd585474d0902118dc0ba8, on the committed tree 798117ee: exit 0 — core # tests 3143 / # pass 3143 / # fail 0, social 619, tree 140, timeline 161, workflow 508, .github/scripts + policy 671, interop 37, live-e2e 34
  • ARCH_DOCS_BASE=f2ef4dfaccedd40315bd585474d0902118dc0ba8 npm --prefix core run arch-docs, on the committed tree: arch-docs: OK (6 changed file(s))
  • node scripts/ds-bot-patches.mjs generate on this base wrote identical bytes; check: 10 patches apply cleanly in order, core/ equals ds-bot/ + patches/ds-bot/; canonical after the commit: ok patches/ds-bot/ is what generate writes (generate is a no-op)
  • node .github/scripts/changelog.mjs check: 17 fragment(s) in changelog.d/ are well formed

Not run: dev/bench.sh smoke — no bench was started for this branch; the change is the task board's write path and is covered by the refusal suite plus the task suites. No real-model end-to-end run.

How an old reader sees it

No API shape, field name or stored value changes. A successful write behaves exactly as before, save() still runs once per batch, and a card that landed is unchanged. Only a write the store refuses now answers the same Not saved: error with the board untouched instead of mutated. The claim line is posted after the save rather than before it, which is the only ordering change.

Dedupe

No other open issue or PR covers this face; #2597/#2602 are the closed validation half before save(), and this is the store-refusal half on the same batch.

Checkpoint

{
  "pr": 2763,
  "claim": "Dev-1",
  "issue": 2761,
  "summary": "track_tasks committed the batch into state.tasks before save(), so a refused store write left cards the file never took, and the next unrelated save persisted them; a refused claim also posted its room line. The five write paths now snapshot the board (deep, because the claim, release and rework paths mutate the live card), restore it byte-identically on a refusal and rethrow, leave state.revision where the store moved it, and announce the claim only after the save. Seven new cases are red on a clean base and green after the fix.",
  "files": [
    "core/src/host/tasks.js",
    "core/test/track-tasks-batch-refusal.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2761-track-tasks-rollback.fixed.md",
    "patches/ds-bot/files/src/host/tasks.js",
    "patches/ds-bot/files/test/track-tasks-batch-refusal.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC npm run verify with ARCH_DOCS_BASE=f2ef4dfa; core arch-docs; node scripts/ds-bot-patches.mjs generate/check/canonical; node .github/scripts/changelog.mjs check",
    "result": "green",
    "at": "5699bdb466204e896fef959c312010da155220c0"
  },
  "next": "waiting for the review side's Verdict naming 5699bdb466204e896fef959c312010da155220c0"
}

…hinkFlowLab#2761)

Every write path committed the batch into `state.tasks` before `save()`, so a
refused store write (a temp name that is not a plain file, a lost lock, a full
disk) left cards the file never took in memory — and the next unrelated save
persisted them. The claimed card also announced its room line before the save,
so a refused claim posted a line for a card nobody held.

Snapshot the board on each write path and put it back when the store refuses:
the claim, release and rework paths mutate the live card in place, so the
snapshot is deep, and the restore writes every key back in order so the board
is byte-identical. `state.revision` is left where the store moved it. The
claim line moves after a successful save, which also keeps `claimCue` out of
that save's bytes (a later write of the same card still persists it).
@HugoXK

HugoXK commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy ready for a Verdict at 5699bdb.

track_tasks committed its batch into state.tasks before save(): the main path staged a copy but the four early paths (claim: true, the handoff pool, rework, release) mutated the live card, and the single save() came after. A store write that refuses therefore left cards the file never took, the next unrelated save wrote them out, and a refused claim still posted its room line. Each of the five save points now snapshots the board first and puts it back when the store refuses (deep, because those four paths mutate the live card), leaves state.revision where the store moved it, and announces the claim only after the save.

Red first on a clean f2ef4dfa worktree carrying only this branch's test file: # tests 10 / # pass 3 / # fail 7 — the three #2597 cases pass, the seven new ones fail (a new batch stays on the board after a refusal, an updated card keeps its mutation and gains claimCue, a ghost card rides the next save onto the disk, a refused claim posts one line, a refused claim leaves the card claimed with a lease, a refused release leaves it open, a refused rework opens a child card). After the fix the same file is 10/10.

Checks on this tip: 19 runs = 16 success + 2 skipped + 1 failure; the only failure is enqueue-guard ("no Verifier report names the tip 5699bdb"), which a Verdict at this tip resolves. tests-ok, merge-gate and policy-sign are green at this tip.

Please give the Verdict on this exact 40-character tip: 5699bdb466204e896fef959c312010da155220c0.

@Birfy

Birfy commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 5699bdb
Written-by: Reviewer

I reviewed the diff itself against base f2ef4dfaccedd40315bd585474d0902118dc0ba8, not the author's narrative. The base ordering matches the issue: core/src/host/tasks.js:1299-1302 commits staged into the live board and announces claims before await save(), and the claim, handoff-claim, rework and release paths mutate the live card before their single save, so a refused store write leaves the batch in memory for the next save to persist. The fix snapshots state.tasks (deep, because those four early paths mutate the live card, not a staging copy) before each of the five write points, restores it in a catch around rt.save() and rethrows the store's error, and moves announceClaim after the save. That is this defect and only this defect; the diff contains no policy/.github/workflow hunk.

I reproduced the evidence on scratch trees: the tip's test file against the base source (only the test file taken from the tip) is red exactly on the seven #2761 cases, and the same file on the tip is green. The base failures show the ghost cards, the mutated stored card and the posted claim line the issue describes, so this is not taken on trust.

Ran:

  • extracted tarballs of tip 5699bdb4 and base f2ef4dfa and read the whole .diff (6 files) → the only production hunk is core/src/host/tasks.js, mirrored byte-identically into patches/ds-bot/files/src/host/tasks.js (cmp identical for source and test mirror)
  • node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/track-tasks-batch-refusal.test.js on the tip → pass: # tests 10 / # pass 10 / # fail 0
  • same command on a base tree carrying only the tip's core/test/track-tasks-batch-refusal.test.js (core/src/host/tasks.js at base) → fail: # tests 10 / # pass 3 / # fail 7, the seven track_tasks commits the batch before save(): a refused store write leaves ghost cards in memory #2761 cases red (ghost cards left in state.tasks, renamed card kept in memory, claim line posted on a refused save, refused claim/release/rework state changed)
  • node --import ./test/harness-resolve.mjs --test test/bot-tasks.test.js test/task-*.test.js test/tasks.test.js test/track-tasks-*.test.js on the tip → 145 pass / 1 fail; the failure is test/inbox-task-status.test.js requiring the esbuild devDependency absent from this scratch tree (ERR_MODULE_NOT_FOUND: esbuild), not a code failure
  • node scripts/lint.mjs on the tip core/ → Found 87 warnings and 0 errors (warnings are pre-existing elsewhere)
  • node .github/scripts/changelog.mjs check on the tip → changelog: 17 fragment(s) in changelog.d/ are well formed

Not run:

  • full npm run verify — not repeated in this session; the tip's tests-ok and Node 24 · DSH 0.2.0-rc.2 checks are green, and I ran the task-side suites and lint myself
  • npm run arch-docs — the scratch tarball has no origin/main, so it answers arch-docs: FAIL — cannot diff against origin/main; the docs edit was read, not regenerated here
  • dev/bench.sh smoke and any real-model end-to-end run

Findings: none. The deep snapshot deviates from the issue's "shallow is enough" note and the PR is right to: the four early paths hold the live card object, which a shallow copy cannot restore. claimCue is now persisted by the next write rather than the claim's write; the PR states this and the claim-line case covers it.

Not merge authority: this PASS clears the review gate for this tip only; the merge queue still judges the entry.

Security fence / display names: not in scope — this change does not touch delivery, social, fencing or model-visible remote text.

@github-actions github-actions Bot added the pass label Nov 10, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 10, 2026
Merged via the queue into ThinkFlowLab:main with commit 2ddec2d Nov 10, 2026
19 checks passed
@Birfy
Birfy deleted the dev/1-track-tasks-rollback branch November 10, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

track_tasks commits the batch before save(): a refused store write leaves ghost cards in memory

2 participants